Node.js 서버의 Graceful Shutdown 구현하기
Node.js 서버의 Graceful Shutdown 구현하기
Graceful Shutdown은 signal을 받자마자 process.exit(0)하는 코드가 아니다. 먼저 readiness를 실패시켜 새 트래픽 유입을 줄이고, HTTP server가 새 연결을 받지 않게 한 뒤 진행 중 요청과 queue job을 제한 시간 안에서 마친다. 마지막으로 DB·Redis·telemetry를 닫고 event loop가 자연스럽게 비게 하며, 종료가 멈출 경우를 대비해 orchestrator grace period보다 짧은 강제 deadline을 둔다.
목차
- #문제가 되는 상황
- #종료는 하나의 상태 전이다
- #SIGTERM과 SIGINT의 의미
- #첫 단계는 Readiness를 내리는 것이다
- #새 HTTP 연결을 중단한다
- #진행 중 요청을 어떻게 셀까
- #DB와 Redis는 요청보다 나중에 닫는다
- #Queue Consumer와 Background Job 종료
- #WebSocket과 Long-Lived Connection
- #강제 종료 Deadline이 필요한 이유
- #두 번 들어온 종료 신호를 처리한다
- #process exit를 너무 일찍 호출하지 않는다
- #Kubernetes Grace Period와 맞추기
- #완성된 Shutdown Manager 예시
- #관측과 통합 테스트
- #결론
- #관련 노트
- #참고 자료
문제가 되는 상황
배포 플랫폼이 기존 Node.js 프로세스에 SIGTERM을 보냈다고 하자.
process.on("SIGTERM", async () => {
await database.close();
process.exit(0);
});
이 코드는 간단하지만 문제가 많다.
- load balancer가 아직 이 instance로 요청을 보낼 수 있다.
- 진행 중 handler가 DB를 쓰는 동안 pool을 먼저 닫는다.
- HTTP response가 client까지 전송되기 전에 process가 종료된다.
- queue message를 처리 중인데 ack와 transaction 사이에서 끊긴다.
- telemetry buffer와 error log가 flush되지 않는다.
- database.close가 멈추면 process가 영원히 종료되지 않을 수 있다.
반대로 자원이 모두 자연스럽게 끝날 때까지 무한 대기하면 배포가 멈추고 결국 orchestrator의 SIGKILL을 받는다. Graceful Shutdown은 안전한 순서와 유한한 시간 예산을 함께 설계하는 일이다.
종료는 하나의 상태 전이다
서버 상태를 명시적으로 관리하면 health check와 request handler가 같은 판단을 공유할 수 있다.
type LifecycleState =
| "STARTING"
| "READY"
| "DRAINING"
| "STOPPED";
class Lifecycle {
private state: LifecycleState = "STARTING";
markReady() {
this.state = "READY";
}
beginDrain() {
if (this.state === "READY") {
this.state = "DRAINING";
}
}
markStopped() {
this.state = "STOPPED";
}
isReady() {
return this.state === "READY";
}
current() {
return this.state;
}
}
stateDiagram-v2
[*] --> STARTING
STARTING --> READY: dependencies initialized
READY --> DRAINING: SIGTERM or SIGINT
DRAINING --> STOPPED: requests and resources closed
STOPPED --> [*]종료 signal을 받으면 가장 먼저 DRAINING으로 이동한다. 이후 새 작업을 받지 않고 이미 시작한 작업만 마친다.
readiness endpoint, structured log와 metric이 같은 lifecycle 상태를 사용하면 “트래픽은 계속 받는데 DB는 닫힌” 모순을 줄일 수 있다.
SIGTERM과 SIGINT의 의미
운영 환경은 보통 SIGTERM으로 정상 종료 기회를 준다. 로컬에서는 Ctrl+C가 SIGINT를 발생시킨다.
process.once("SIGTERM", () => {
void shutdown.initiate("SIGTERM");
});
process.once("SIGINT", () => {
void shutdown.initiate("SIGINT");
});
signal handler를 등록하면 Node.js의 기본 종료 동작 대신 애플리케이션이 lifecycle을 책임진다. 모든 정리 후 event loop가 비거나 exit code를 설정해야 한다.
SIGKILL은 process가 catch하거나 cleanup할 수 없다.
SIGTERM
→ handler 실행 가능
→ grace period 안에 정리
SIGKILL
→ 즉시 종료
→ handler와 finally 실행 불가
따라서 SIGTERM handler가 “나중에 언젠가 끝나겠지”라고 무한 대기하면 결국 SIGKILL로 더 거칠게 종료된다.
Container에서 Node.js가 signal을 실제로 받는지도 확인한다. shell이 PID 1이고 signal을 child에 전달하지 않는 형태를 피한다.
# signal 전달에 명확한 exec form
CMD ["node", "dist/server.js"]
package script와 process manager를 사용한다면 signal forwarding 설정을 검증한다. 로컬에서 handler가 동작한다고 container에서도 같다고 가정하지 않는다.
첫 단계는 Readiness를 내리는 것이다
종료를 시작한 instance는 새 요청을 받지 않아야 한다.
app.get("/health/ready", (req, res) => {
if (!lifecycle.isReady()) {
return res.status(503).json({
status: "not-ready",
lifecycle: lifecycle.current(),
});
}
return res.status(200).json({ status: "ready" });
});
shutdown 시작:
lifecycle.beginDrain();
orchestrator와 load balancer가 readiness 변화나 endpoint removal을 반영하는 데 시간이 걸릴 수 있다. 아주 짧은 propagation delay를 둔 뒤 listener를 닫는 구조도 검토할 수 있다.
T0: lifecycle=DRAINING
T0+Δ: load balancer가 endpoint 제외
T0+small delay: server.close
하지만 무조건 긴 sleep을 넣으면 grace period를 낭비한다. 플랫폼의 endpoint 반영 시간과 connection draining 동작을 측정한다.
Liveness는 종료 중이라고 process를 강제로 재시작하게 만들지 않도록 구분한다. 일반적으로 readiness는 트래픽 수신 가능 여부, liveness는 process가 복구 불가능하게 멈췄는지 판단한다.
새 HTTP 연결을 중단한다
Node.js HTTP server의 server.close()는 새 connection을 받지 않게 하고 기존 connection의 종료를 기다리는 핵심 API다.
function closeHttpServer(server: http.Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
Node.js 버전에 따라 idle keep-alive connection 처리 동작이 달라졌다. 현재 공식 문서 기준으로 Node.js 19부터 server.close()가 idle connection도 닫는다. Node 18.2부터 제공되는 closeIdleConnections()를 하위 버전 호환에 사용할 수 있으며, 공식 문서는 race를 피하기 위해 server.close() 뒤에 호출할 것을 권장한다.
const closePromise = closeHttpServer(server);
server.closeIdleConnections?.();
await closePromise;
server.closeAllConnections()는 active HTTP connection까지 강제로 닫으므로 graceful 시작 단계에서 바로 호출하면 안 된다. deadline이 끝난 뒤 마지막 수단으로 사용한다.
forceTimer = setTimeout(() => {
server.closeAllConnections?.();
}, forceAfterMs);
예전 blog의 keep-alive workaround를 그대로 복사하거나 최신 동작을 구버전에 적용하면 connection이 남거나 너무 빨리 끊길 수 있다. Node.js HTTP 공식 문서의 version history를 확인한다.
진행 중 요청을 어떻게 셀까
server.close() promise만 기다릴 수도 있지만 애플리케이션 수준 active request 수를 관측하면 drain 상태를 설명하기 쉽다.
class ActiveRequestTracker {
private active = 0;
private waiters: Array<() => void> = [];
middleware(): RequestHandler {
return (req, res, next) => {
this.active += 1;
let completed = false;
const finish = () => {
if (completed) return;
completed = true;
this.active -= 1;
if (this.active === 0) {
for (const resolve of this.waiters.splice(0)) {
resolve();
}
}
};
res.once("finish", finish);
res.once("close", finish);
next();
};
}
count() {
return this.active;
}
waitForZero(): Promise<void> {
if (this.active === 0) {
return Promise.resolve();
}
return new Promise((resolve) => this.waiters.push(resolve));
}
}
finish와 close가 둘 다 발생할 수 있으므로 한 번만 감소시키는 guard가 필요하다. request 시작 middleware는 route보다 앞에 등록한다.
shutdown 때:
logger.info({
event: "shutdown_drain_started",
activeRequests: requestTracker.count(),
});
await Promise.all([
closeHttpServer(server),
requestTracker.waitForZero(),
]);
HTTP response가 완료됐다고 fire-and-forget background task까지 끝난 것은 아니다. durable 작업은 queue/outbox로 넘기고, process local 작업은 별도 tracker에 등록한다.
DB와 Redis는 요청보다 나중에 닫는다
다음 순서는 잘못됐다.
SIGTERM
→ database.close()
→ 진행 중 request가 DB query 실행
→ connection closed error
올바른 방향:
1. 새 요청 차단
2. 진행 중 요청 완료
3. 더 이상 DB를 사용할 request가 없음을 확인
4. DB pool close
await drainHttpTraffic();
await database.close();
await redis.quit();
다만 HTTP 요청과 queue worker가 같은 process에서 DB를 공유한다면 둘 다 drain한 후 닫아야 한다.
await Promise.all([
httpDrainPromise,
queueDrainPromise,
schedulerDrainPromise,
]);
await Promise.allSettled([
database.close(),
redis.quit(),
]);
Promise.allSettled는 한 자원 종료 실패 때문에 나머지 close를 건너뛰지 않게 한다. 각 결과는 log와 exit code에 반영한다.
Redis quit() 또는 client-specific graceful close가 pending command를 어떻게 처리하는지 library version을 확인한다. 즉시 socket destroy와 구분한다.
시작할 때 DB → repository → HTTP 순서로 준비했다면 종료는 HTTP → repository 작업 drain → DB 순서가 자연스럽다.
Queue Consumer와 Background Job 종료
message consumer는 새 message 가져오기를 먼저 멈춘 뒤 현재 job을 완료해야 한다.
async function drainQueueWorker(worker: QueueWorker): Promise<void> {
await worker.pauseFetching();
await worker.waitForActiveJobs();
await worker.close();
}
sequenceDiagram
participant S as Shutdown
participant Q as Queue Worker
participant B as Broker
participant D as Database
S->>Q: pause fetching
Q->>D: finish active transaction
D-->>Q: commit
Q->>B: ack message
Q-->>S: active jobs=0ack 시점이 중요하다.
ack 먼저 → process crash → 작업 유실 가능
처리 commit 먼저, ack 나중 → crash 시 재전달 가능
재전달을 허용하고 job handler를 멱등하게 만든다.
긴 job이 grace period보다 오래 걸릴 수 있다면 다음 중 하나가 필요하다.
- 작업을 작은 checkpoint 단위로 나눔
- lease를 연장하고 다른 worker가 takeover
- 중간 상태를 durable storage에 기록
- 종료 시 nack/requeue
- 별도 worker deployment로 HTTP와 분리
cron scheduler도 새 실행을 막는다.
scheduler.pause();
await scheduler.waitForRunningJobs();
단순 setInterval callback이 실행 중인지 추적하지 않으면 process 종료 시 중간에서 끊긴다.
WebSocket과 Long-Lived Connection
WebSocket, SSE와 long polling은 수분·수시간 connection을 유지할 수 있어 일반 HTTP drain만 기다리면 종료되지 않는다.
종료 protocol을 명시한다.
1. 새 WebSocket upgrade 차단
2. client에 server_restarting message 전송
3. close code와 재연결 backoff 안내
4. 짧은 유예 뒤 남은 socket terminate
async function drainWebSockets(server: WebSocketServer) {
const closePromises = [...server.clients].map((socket) =>
closeSocketGracefully(socket, {
code: 1012,
reason: "service restart",
}),
);
await Promise.allSettled(closePromises);
}
1012 등 close code 지원과 client library 동작을 실제 환경에서 확인한다. 모든 client가 close frame에 응답하지 않으므로 deadline 뒤 socket destroy가 필요하다.
SSE client는 연결이 끊긴 뒤 Last-Event-ID로 재개할 수 있게 durable event stream을 사용한다. process memory에만 event offset이 있으면 재시작 후 이어갈 수 없다.
강제 종료 Deadline이 필요한 이유
종료 작업 중 하나가 영원히 끝나지 않을 수 있다.
DB close callback 미도착
queue job 외부 API 무한 대기
WebSocket client close ack 없음
telemetry exporter hang
전체 shutdown에 deadline을 둔다.
class ShutdownTimeoutError extends Error {}
function withDeadline<T>(
promise: Promise<T>,
timeoutMs: number,
onTimeout: () => void,
): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) => {
const timer = setTimeout(() => {
onTimeout();
reject(new ShutdownTimeoutError());
}, timeoutMs);
timer.unref();
}),
]);
}
시간 예산 예시:
orchestrator grace period: 30s
application deadline: 25s
telemetry final flush: 2s reserved
safety margin: 3s
애플리케이션 deadline이 grace period와 같으면 timeout log와 metric을 flush할 시간 없이 SIGKILL될 수 있다.
deadline 도달 시:
server.closeAllConnections?.();
queueWorker.forceClose();
process.exitCode = 1;
강제 종료는 정상 성공으로 기록하지 않는다. incomplete request와 job이 재시도 가능한지 확인한다.
두 번 들어온 종료 신호를 처리한다
SIGTERM과 SIGINT가 연달아 들어오거나 process manager가 signal을 반복할 수 있다. shutdown은 멱등해야 한다.
class ShutdownManager {
private shutdownPromise: Promise<void> | null = null;
initiate(reason: string): Promise<void> {
if (this.shutdownPromise) {
return this.shutdownPromise;
}
this.shutdownPromise = this.run(reason);
return this.shutdownPromise;
}
private async run(reason: string) {
// shutdown steps
}
}
두 번째 signal을 “즉시 강제 종료” 의도로 사용할 수도 있다.
let signalCount = 0;
function handleSignal(signal: NodeJS.Signals) {
signalCount += 1;
if (signalCount === 1) {
void shutdownManager.initiate(signal);
return;
}
logger.warn({ signal }, "second signal, forcing shutdown");
shutdownManager.force();
}
운영 플랫폼이 signal을 중복 전달할 수 있다면 두 번째 signal이 곧 사용자 강제 의도인지 신중히 판단한다. 최소한 자원 close를 두 번 호출해 예외가 나지 않게 한다.
process exit를 너무 일찍 호출하지 않는다
process.exit()는 pending asynchronous I/O가 끝나기를 기다리지 않고 process를 종료할 수 있다.
logger.info("shutdown complete");
process.exit(0);
logger가 비동기 transport라면 마지막 로그가 유실될 수 있다. 가능하면 모든 listener와 handle을 닫고 process.exitCode만 설정해 event loop가 자연스럽게 비게 한다.
process.exitCode = shutdownSucceeded ? 0 : 1;
필요한 logger/telemetry flush를 명시적으로 기다린다.
await withTimeout(logger.flush(), 1000);
await withTimeout(tracer.shutdown(), 1000);
Node.js의 exit event에서는 비동기 작업을 예약해도 완료할 수 없다. 공식 문서에 따르면 event loop를 더 진행할 방법이 없는 시점이다.
process.on("exit", () => {
// 동기적인 최종 표시만 가능
});
beforeExit도 SIGTERM이나 명시적 process.exit() cleanup의 대체로 사용하지 않는다.
Kubernetes Grace Period와 맞추기
Kubernetes는 일반적으로 Pod 종료 시 grace period 안에서 container main process에 TERM signal을 보내고, 시간이 끝나면 남은 process에 KILL을 보낸다. preStop hook이 있으면 그 실행 시간도 전체 terminationGracePeriodSeconds에 포함된다.
spec:
terminationGracePeriodSeconds: 30
containers:
- name: api
image: sample-api
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 3"]
위 3초는 30초에 더해지는 시간이 아니라 그 안에서 소비된다. 애플리케이션에 남는 시간은 줄어든다.
30s grace period
- 3s preStop
- endpoint propagation
- 2s safety margin
= 실제 drain 예산
Kubernetes 공식 문서는 terminating endpoint가 일반 traffic을 계속 받지 않도록 control plane이 endpoint 상태를 반영한다고 설명하지만 네트워크와 load balancer propagation이 즉시 0은 아닐 수 있다. 애플리케이션 readiness와 server close를 함께 사용한다.
정확한 lifecycle은 Kubernetes Pod Lifecycle 문서와 Container Lifecycle Hooks 문서를 기준으로 확인한다.
완성된 Shutdown Manager 예시
아래 코드는 구조를 설명하기 위한 재구성 예시다. 사용하는 HTTP framework, queue와 client library API에 맞춰 조정해야 한다.
type ShutdownDependencies = {
lifecycle: Lifecycle;
httpServer: http.Server;
requests: ActiveRequestTracker;
queue: QueueWorker;
scheduler: Scheduler;
websockets: WebSocketDrain;
database: DatabaseClient;
redis: RedisClient;
telemetry: Telemetry;
};
class ShutdownManager {
private running: Promise<void> | null = null;
private forceTimer: NodeJS.Timeout | null = null;
constructor(
private readonly deps: ShutdownDependencies,
private readonly deadlineMs: number,
) {}
initiate(reason: string): Promise<void> {
if (this.running) return this.running;
this.running = this.run(reason);
return this.running;
}
private async run(reason: string): Promise<void> {
const startedAt = performance.now();
this.deps.lifecycle.beginDrain();
logger.info({
event: "shutdown_started",
reason,
activeRequests: this.deps.requests.count(),
});
this.forceTimer = setTimeout(() => {
logger.error({
event: "shutdown_deadline_exceeded",
activeRequests: this.deps.requests.count(),
});
this.deps.httpServer.closeAllConnections?.();
process.exitCode = 1;
}, this.deadlineMs);
this.forceTimer.unref();
try {
this.deps.scheduler.pause();
await this.deps.queue.pauseFetching();
const serverClosed = closeHttpServer(this.deps.httpServer);
this.deps.httpServer.closeIdleConnections?.();
await withDeadline(
Promise.all([
serverClosed,
this.deps.requests.waitForZero(),
this.deps.queue.waitForActiveJobs(),
this.deps.scheduler.waitForRunningJobs(),
this.deps.websockets.drain(),
]),
this.deadlineMs - 3_000,
() => this.deps.httpServer.closeAllConnections?.(),
);
const closeResults = await Promise.allSettled([
this.deps.queue.close(),
this.deps.database.close(),
this.deps.redis.quit(),
]);
const failed = closeResults.filter(
(result) => result.status === "rejected",
);
process.exitCode = failed.length === 0 ? 0 : 1;
await withTimeout(this.deps.telemetry.shutdown(), 1_500);
this.deps.lifecycle.markStopped();
logger.info({
event: "shutdown_completed",
durationMs: performance.now() - startedAt,
resourceCloseFailures: failed.length,
});
} catch (error) {
process.exitCode = 1;
logger.error({
event: "shutdown_failed",
error: serializeSafeError(error),
});
} finally {
if (this.forceTimer) {
clearTimeout(this.forceTimer);
}
}
}
force() {
this.deps.httpServer.closeAllConnections?.();
process.exitCode = 1;
}
}
이 예시에서도 logger의 마지막 flush 순서, timer가 event loop를 붙잡는지, close callback error type 등은 실제 library로 테스트해야 한다.
관측과 통합 테스트
shutdown은 배포 때만 실행되는 production code다. 자동 테스트와 staging 배포에서 검증한다.
진행 중 요청이 완료되는지
it("SIGTERM 후 진행 중 요청은 완료하고 새 요청은 거부한다", async () => {
const slowRequest = startSlowRequest(app);
await waitUntilHandlerStarted();
process.kill(process.pid, "SIGTERM");
await waitUntilNotReady();
await expect(sendNewRequest(app)).rejects.toBeDefined();
await expect(slowRequest).resolves.toMatchObject({ status: 200 });
});
실제 signal과 port를 쓰는 child process 통합 테스트가 더 정확하다.
Queue 재전달
처리 중 worker를 deadline으로 종료한 뒤 ack되지 않은 message가 다른 worker에 재전달되고 멱등하게 완료되는지 본다.
Keep-Alive
하나의 keep-alive connection에서 shutdown 뒤 새 request가 계속 들어오지 않는지 Node version별로 확인한다.
Deadline
영원히 끝나지 않는 fake resource를 주입해 application deadline 전에 강제 경로와 exit code 1이 설정되는지 본다.
관측 지표:
shutdown_duration_seconds
shutdown_active_requests
shutdown_forced_total
shutdown_resource_close_failures_total
requests_aborted_during_shutdown_total
queue_jobs_requeued_during_shutdown_total
구조화 로그:
{
"event": "shutdown_completed",
"reason": "SIGTERM",
"durationMs": 1830,
"forced": false,
"activeRequestsAtStart": 12
}
배포 metric에서 forced shutdown과 5xx spike가 0인지 확인한다. 평균 종료 시간뿐 아니라 p99가 grace period에 가까워지는지도 본다.
결론
Node.js Graceful Shutdown은 새 일을 받지 않고 이미 시작한 일을 유한한 시간 안에 끝낸 뒤 의존 자원을 역순으로 닫는 lifecycle이다. SIGTERM을 받으면 readiness를 먼저 내리고 server.close()로 새 connection을 차단한다. 진행 중 HTTP 요청, queue job, scheduler와 WebSocket을 drain한 다음 DB·Redis·telemetry를 닫아야 한다.
process.exit(0)를 바로 호출하면 response와 로그가 유실될 수 있으므로 process.exitCode를 설정하고 event loop가 자연스럽게 비게 한다. 반대로 무한 대기는 결국 SIGKILL을 부르므로 orchestrator grace period보다 짧은 application deadline과 강제 connection close 경로를 둔다. Node.js 버전별 keep-alive 처리, Kubernetes preStop을 포함한 전체 시간 예산을 확인하고 실제 signal·slow request·queue 재전달을 통합 테스트로 검증해야 한다.
관련 노트
- Express 미들웨어의 실행 순서 이해하기
- Express 에러 미들웨어가 네 개의 인자를 받는 이유
- Readiness와 Liveness Check의 차이
- 구조화 로그에 Request ID 넣기
- 재시도에 지수 백오프와 지터가 필요한 이유
- nodejs process